同一份索引、同一批題目:精確檢索答對 10/15,HNSW 用預設參數答對 7/15。
掉的那三題,top-1 不是「排錯的條文」——是三個根本不存在的隨機向量。
而你為此換到的,是每題 25ms 變 0.77ms。
efSearch 開多大昨天欠的債:Day 9 文末我說「五萬塊向量還逐一算相似度就不行了,該讓向量資料庫上場」。今天先讓向量資料庫的心臟上場——向量索引。metadata、持久化、過濾那些讓它配得上「資料庫」三個字的東西,明天再來。
Day 8、Day 9 的檢索是同一招:把問題向量跟索引裡每一塊算一次相似度,排序取最高。59 塊向量這樣掃,一題不到 1ms,完全沒有問題——問題是這句話裡的「59」。真實系統的知識庫是幾萬、幾十萬塊起跳。掃描是 O(N),N 放大一千倍,延遲就放大一千倍。
所以今天的實驗只做一件事:把 N 灌大,量給你看。
語料還是那份 59 條的虛構《員工工作規則》(技術示範用,非任何公司實際規章),15 題測試題也原封不動——變的只有索引規模:我用 0 元的合成向量,把索引從 59 塊灌到 50,059 塊。
pip install faiss-cpu
FAISS 是 Meta 開源的向量索引庫,幾乎所有向量資料庫的檢索核心都是它或它的同類。過去它在 Windows 上是出名的難裝,但 2026 年這件事過期了——v1.14.2 起官方直接發 PyPI wheel,三大平台都是一行裝好。
今天用它的兩種索引,同一套 API、同一份資料,只換索引型別——這樣量出來的差異才只屬於演算法,不摻雜實作差異:
def build_flat(vectors):
"""精確:內積暴力掃描(向量已正規化,內積=餘弦相似度),結果保證正確"""
index = faiss.IndexFlatIP(vectors.shape[1])
index.add(vectors)
return index
def build_hnsw(vectors, m=32, ef_construction=200):
"""近似:HNSW 圖。查詢走圖導航而非全量掃描,快,但不保證找到真正的最近鄰"""
index = faiss.IndexHNSWFlat(vectors.shape[1], m, faiss.METRIC_INNER_PRODUCT)
index.hnsw.efConstruction = ef_construction
index.add(vectors)
return index
兩種索引查詢都是同一行:index.search(question_vector, top_k)。
用文字圖解說差在哪:
Flat(精確) HNSW(近似)
問題向量 問題向量
│ │
▼ ▼
跟全部 50,059 塊 從圖的入口點出發,
逐一算內積 ──── O(N) 沿著「越走越近」的邊
│ 跳躍前進 ──── 近似 O(log N)
▼ │
排序取 top-k ▼
【保證正確】 走到局部最近的鄰域取 top-k
【可能走丟,不保證正確】
HNSW 查詢期有一顆重要的旋鈕 efSearch:導航時同時追蹤幾條候選路徑。開越大越不容易走丟(越準),但也越慢。FAISS 的預設值是 16——記住這個數字,等下它會出事。
59 條條文只有 59 個向量,要 5 萬塊怎麼辦?拿真文本去算 embedding 要花錢也要等;我用合成的:
centroids = rng.standard_normal((500, 1536), dtype=np.float32) # 500 個隨機質心
points = np.repeat(centroids, 100, axis=0) # 每個質心複製 100 份
points += 0.35 * rng.standard_normal((50000, 1536), dtype=np.float32) # 加擾動
faiss.normalize_L2(points)
兩個設計重點:
59 條真實向量放在 id 0~58,跟 5 萬個合成向量疊在同一份索引裡。
index.search() 本身,問題向量事先算好——量的是索引,不是 embedding API 往返。每個設定 15 題 × 20 次=300 個樣本,取中位數與 p95。如果只跑 demo 不量測會錯過什麼?demo 用預設參數查一題,0.8ms 就回來了,結果看起來也像模像樣——你會直接上線。掉題這件事,不逐題對答案根本看不見。
先看總表(完整逐題明細在 repo 的 experiment_result.md):
表一:單題查詢延遲(50,059 塊、1536 維)
| 引擎 | 中位數 | p95 | 相對 Flat |
|---|---|---|---|
| Flat(精確,單執行緒) | 24.58 ms | 37.47 ms | 1x |
| HNSW efSearch=8 | 0.566 ms | 0.992 ms | 43x |
| HNSW efSearch=16(預設) | 0.771 ms | 2.351 ms | 32x |
| HNSW efSearch=64 | 2.007 ms | 4.512 ms | 12x |
| HNSW efSearch=256 | 5.062 ms | 6.946 ms | 5x |
表二:HNSW 的代價——recall 與命中率
| efSearch | recall@1 | recall@10 | Day 9 題組命中 |
|---|---|---|---|
| Flat(對照) | 1.00 | 1.00 | 10/15 |
| 8 | 0.73 | 0.73 | 7/15 |
| 16(預設) | 0.80 | 0.80 | 7/15 |
| 32 | 0.87 | 0.87 | 8/15 |
| 64 | 1.00 | 1.00 | 10/15 |
| 128 | 1.00 | 1.00 | 10/15 |
| 256 | 1.00 | 1.00 | 10/15 |
先誠實講一個反直覺的結果:5 萬塊向量,精確掃描一題也才 25ms。如果我在這裡收工,結論會是「Day 9 的預告嚇唬人,根本不用上 ANN」。
但 25ms 是單題視角。換成服務視角:一顆核心每秒只能服務 40 題,而且 O(N) 意味著資料到 50 萬塊時變成 250ms——使用者按下送出後明顯卡一下的等級。逼你上 ANN 的從來不是「一題太慢」,是 QPS × 資料成長的乘積。
efSearch=16 是 FAISS 的預設值。表二那一行:recall 0.80、15 題掉 3 題。
掉的三題長這樣(完整解剖在 experiment_result.md):
| 題目 | 預期 | HNSW top-1 變成 |
|---|---|---|
| 病假連續請幾天以上需要附診斷證明? | 第 24 條 | 合成向量 #8000 |
| 每月加班時數上限是多少小時? | 第 18 條 | 合成向量 #30339 |
| 國內出差住宿費每晚上限是多少? | 第 40 條 | 合成向量 #15453 |
注意錯的方式:不是第二名擠掉第一名,是回傳了一個跟問題毫無關係的隨機向量。HNSW 走丟的時候,交出來的是「它走得到的範圍內最好的」,而不是全域最好的。在 RAG 裡這意味著:LLM 拿到的 context 不是次佳條文,是垃圾——而它還是會一本正經地用這份垃圾回答。
而且這三題在 Flat 底下全對,其中兩題(第 18 條、第 40 條)還是全場相似度最高的題目(0.72、0.73)——掉題跟「題目難不難」無關,跟圖的形狀有關。
efSearch 開到 64:recall 1.00、15 題與 Flat 全部一致,延遲 2ms——仍比精確掃描快 12 倍。在這份資料上,「準」與「快」可以同時要。
但重點是那句「在這份資料上」。歸零點是量出來的,不是查文件查出來的:你的向量分布不同、規模不同,你的 64 就在別的位置。這也是為什麼今天的實驗腳本值得留著——換上你自己的資料,掃一輪 efSearch,找到自己的歸零點再上線。
有人會問:灌了 5 萬個假向量,答案會不會被假向量搶走?實驗驗證過:15 題的 Flat 精確結果裡,合成向量在 top-10 的 150 個位置中出現 0 次;Flat 命中率 10/15,跟 Day 9 用 59 塊索引時一模一樣。換引擎、灌干擾,精確檢索的答案一個都沒變——所以表二裡 HNSW 掉的每一題,都只能算在「近似」頭上。
| 情境 | 建議 | 代價 |
|---|---|---|
| 向量數萬級、單人或低 QPS | Flat 就好,別急著上 ANN | 每題幾十 ms |
| 十萬級以上、或 QPS 上得來 | HNSW,但用自己的資料掃 efSearch 找歸零點 |
建圖幾分鐘、記憶體多一份、沒調參會掉題 |
| 準確度不能妥協(法遵、醫療) | Flat,或 efSearch 開大並持續監控 recall |
延遲、算力 |
| 只是想 demo | 隨便,但別把 demo 的預設參數帶上線 | 上線後才發現的掉題 |
近似檢索的代價不是「慢一點的正確」,而是「快非常多、但偶爾完全走丟」——而預設參數不會告訴你它走丟了。
丟個問題給你:你現在線上那套 RAG,向量庫的 efSearch(或 ef、search_k,每家名字不同)是多少?是量出來的,還是預設值?歡迎留言聊聊。
明天 Day 11:今天這份索引重開機就沒了、加一塊向量要整棟重建、想按部門過濾條文?做不到。這些讓「索引」升級成「資料庫」的東西——metadata、持久化、過濾——昨天承諾的向量資料庫,明天真的上場:Chroma。
GitHub:https://github.com/wp900622/TrustRAG (day10_vector_index/,clone 即可重現全部數字)
參考資料: